其他
7min 到 40s:SpringBoot 启动优化实践!
0 背景
SpringApplicationRunListener
、BeanPostProcessor
原理和源码调试等手段排查发现,在 Bean 扫描和 Bean 注入这个两个阶段有很大的性能瓶颈。基于 SpringApplicationRunListener 原理观察 SpringBoot 启动 run 方法; 基于 BeanPostProcessor 原理监控 Bean 注入耗时; SpringBoot Cache 自动化配置原理; SpringBoot 自动化配置原理及 starter 改造;
1 耗时问题排查
排查 SpringBoot 服务的启动过程; 排查 Bean 的初始化耗时;
1.1 观察 SpringBoot 启动 run 方法
ApplicationContext
构造和 ApplicationContext
启动两部分,即通过构造函数实例化 ApplicationContext
对象,并调用其 run
方法启动服务:public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
public static ConfigurableApplicationContext run(Class<?>[] primarySources, String[] args) {
return new SpringApplication(primarySources).run(args);
}
ApplicationContext
对象构造过程,主要做了自定义 Banner 设置、应用类型推断、配置源设置等工作,不做特殊扩展的话,大部分项目都是差不多的,不太可能引起耗时问题。通过在 run
方法中打断点,启动后很快就运行到断点位置,也能验证这一点。 接下就是重点排查 run
方法的启动过程中有哪些性能瓶颈?SpringBoot 的启动过程非常复杂,庆幸的是 SpringBoot 本身提供的一些机制,将 SpringBoot 的启动过程划分了多个阶段,这个阶段划分的过程就体现在 SpringApplicationRunListener
接口中,该接口将 ApplicationContext
对象的 run
方法划分成不同的阶段:// run 方法第一次被执行时调用,早期初始化工作
void starting();
// environment 创建后,ApplicationContext 创建前
void environmentPrepared(ConfigurableEnvironment environment);
// ApplicationContext 实例创建,部分属性设置了
void contextPrepared(ConfigurableApplicationContext context);
// ApplicationContext 加载后,refresh 前
void contextLoaded(ConfigurableApplicationContext context);
// refresh 后
void started(ConfigurableApplicationContext context);
// 所有初始化完成后,run 结束前
void running(ConfigurableApplicationContext context);
// 初始化失败后
void failed(ConfigurableApplicationContext context, Throwable exception);
}
SpringApplicationRunListener
接口只有一个实现类:EventPublishingRunListener
,该实现类作用:通过观察者模式的事件机制,在 run
方法的不同阶段触发 Event
事件,ApplicationListener
的实现类们通过监听不同的 Event
事件对象触发不同的业务处理逻辑。通过自定义实现 ApplicationListener
实现类,可以在 SpringBoot 启动的不同阶段,实现一定的处理,可见SpringApplicationRunListener
接口给SpringBoot
带来了扩展性。
EventPublishingRunListener
的功能,但是可以通过 SpringApplicationRunListener
原理,添加一个自定义的实现类,在不同阶段结束时打印下当前时间,通过计算不同阶段的运行时间,就能大体定位哪些阶段耗时比较高,然后重点排查这些阶段的代码。 先看下 SpringApplicationRunListener
的实现原理,其划分不同阶段的逻辑体现在 ApplicationContext
的 run
方法中:...
// 加载所有 SpringApplicationRunListener 的实现类
SpringApplicationRunListeners listeners = getRunListeners(args);
// 调用了 starting
listeners.starting();
try {
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
// 调用了 environmentPrepared
ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
configureIgnoreBeanInfo(environment);
Banner printedBanner = printBanner(environment);
context = createApplicationContext();
exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[] { ConfigurableApplicationContext.class }, context);
// 内部调用了 contextPrepared、contextLoaded
prepareContext(context, environment, listeners, applicationArguments, printedBanner);
refreshContext(context);
afterRefresh(context, applicationArguments);
stopWatch.stop();
if (this.logStartupInfo) {
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
}
// 调用了 started
listeners.started(context);
callRunners(context, applicationArguments);
}
catch (Throwable ex) {
// 内部调用了 failed
handleRunFailure(context, ex, exceptionReporters, listeners);
throw new IllegalStateException(ex);
}
try {
// 调用了 running
listeners.running(context);
}
catch (Throwable ex) {
handleRunFailure(context, ex, exceptionReporters, null);
throw new IllegalStateException(ex);
}
return context;
}
run
方法中 getRunListeners(args)
通过 SpringFactoriesLoader
加载 classpath
下 META-INF/spring.factories
中配置的所有 SpringApplicationRunListener
的实现类,通过反射实例化后,存到局部变量 listeners
中,其类型为 SpringApplicationRunListeners
;然后在 run
方法不同阶段通过调用 listeners
的不同阶段方法来触发 SpringApplicationRunListener
所有实现类的阶段方法调用。SpringApplicationRunListener
的自定义实现类,在实现接口不同阶段方法时,打印当前时间;并在 META-INF/spring.factories
中配置该类后,该类也会实例化,存到 listeners
中;在不同阶段结束时打印结束时间,以此来评估不同阶段的执行耗时。 在项目中添加实现类 MySpringApplicationRunListener
:public class MySpringApplicationRunListener implements SpringApplicationRunListener {
// 这个构造函数不能少,否则反射生成实例会报错
public MySpringApplicationRunListener(SpringApplication sa, String[] args) {
}
@Override
public void starting() {
log.info("starting {}", LocalDateTime.now());
}
@Override
public void environmentPrepared(ConfigurableEnvironment environment) {
log.info("environmentPrepared {}", LocalDateTime.now());
}
@Override
public void contextPrepared(ConfigurableApplicationContext context) {
log.info("contextPrepared {}", LocalDateTime.now());
}
@Override
public void contextLoaded(ConfigurableApplicationContext context) {
log.info("contextLoaded {}", LocalDateTime.now());
}
@Override
public void started(ConfigurableApplicationContext context) {
log.info("started {}", LocalDateTime.now());
}
@Override
public void running(ConfigurableApplicationContext context) {
log.info("running {}", LocalDateTime.now());
}
@Override
public void failed(ConfigurableApplicationContext context, Throwable exception) {
log.info("failed {}", LocalDateTime.now());
}
}
resources
文件下的 META-INF/spring.factories
文件中配置上该类:org.springframework.boot.SpringApplicationRunListener=\
com.xxx.ad.diagnostic.tools.api.MySpringApplicationRunListener
run
方法中是通过getSpringFactoriesInstances
方法来获取META-INF/spring.factories
下配置的SpringApplicationRunListener
的实现类,其底层是依赖SpringFactoriesLoader
来获取配置的类的全限定类名,然后反射生成实例; 这种方式在 SpringBoot 用的非常多,如EnableAutoConfiguration
、ApplicationListener
、ApplicationContextInitializer
等。
MySpringApplicationRunListener
的日志输出,发现主要耗时都在 contextLoaded
和 started
两个阶段之间,在这两个阶段之间调用了 2 个方法:refreshContext
和 afterRefresh
方法,而 refreshContext
底层调用的是 AbstractApplicationContext#refresh
,Spring 初始化 context 的核心方法之一就是这个 refresh
。在 invokeBeanFactoryPostProcessors(beanFactory)
方法中,调用了所有注册的BeanFactory
的后置处理器;其中, ConfigurationClassPostProcessor
这个后置处理器贡献了大部分的耗时;查阅相关资料,该后置处理器相当重要,主要负责 @Configuration
、@ComponentScan
、@Import
、@Bean
等注解的解析;继续调试发现,主要耗时都花在主配置类的 @ComponentScan
解析上,而且主要耗时还是在解析属性basePackages
;
@SpringBootApplication
注解的 scanBasePackages
属性:basePackages
所有路径下的 class,对于可作为 Bean 的对象,生成其 BeanDefinition
;如果遇到 @Configuration
注解的配置类,还得递归解析其 @ComponentScan
。 至此,服务启动缓慢的原因就找到了:作为数据平台,我们的服务引用了很多第三方依赖服务,这些依赖往往提供了对应业务的完整功能,所以提供的 jar 包非常大; 扫描这些包路径下的 class 非常耗时,很多 class 都不提供 Bean,但还是花时间扫描了; 每添加一个服务的依赖,都会线性增加扫描的时间;
是否所有的 class 都需要扫描,是否可以只扫描那些提供 Bean 的 class? 扫描出来的 Bean 是否都需要?我只接入一个功能,但是注入了所有的 Bean,这似乎不太合理?
1.2 监控 Bean 注入耗时
BeanPostProcessor
接口来监控 Bean 的注入耗时,BeanPostProcessor
是 Spring 提供的 Bean 初始化前后的 IOC 钩子,用于在 Bean 初始化的前后执行一些自定义的逻辑:// 初始化前
default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
// 初始化后
default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
return bean;
}
}
BeanPostProcessor
接口的实现类,其前后置处理过程体现在 AbstractAutowireCapableBeanFactory#doCreateBean
,这也是 Spring 中非常重要的一个方法,用于真正实例化 Bean 对象,通过 BeanFactory#getBean
方法一路 Debug 就能找到。在该方法中调用了 initializeBean
方法:...
Object wrappedBean = bean;
if (mbd == null || !mbd.isSynthetic()) {
// 应用所有 BeanPostProcessor 的前置方法
wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
}
try {
invokeInitMethods(beanName, wrappedBean, mbd);
}
catch (Throwable ex) {
throw new BeanCreationException(
(mbd != null ? mbd.getResourceDescription() : null),
beanName, "Invocation of init method failed", ex);
}
if (mbd == null || !mbd.isSynthetic()) {
// 应用所有 BeanPostProcessor 的后置方法
wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
}
return wrappedBean;
}
BeanPostProcessor
原理,在前置处理时记录下当前时间,在后置处理时,用当前时间减去前置处理时间,就能知道每个 Bean 的初始化耗时,下面是我的实现:public class TimeCostBeanPostProcessor implements BeanPostProcessor {
private Map<String, Long> costMap = Maps.newConcurrentMap();
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
costMap.put(beanName, System.currentTimeMillis());
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (costMap.containsKey(beanName)) {
Long start = costMap.get(beanName);
long cost = System.currentTimeMillis() - start;
if (cost > 0) {
costMap.put(beanName, cost);
System.out.println("bean: " + beanName + "\ttime: " + cost);
}
}
return bean;
}
}
BeanPostProcessor
的逻辑是在 Beanfactory
准备好后处理的,就不需要通过 SpringFactoriesLoader
加载了,直接 @Component
注入即可。2 优化方案
2.1 如何解决扫描路径过多?
scanBasePackages
中添加了其服务路径:"com.xxx.ad.upm",通过扫描整个服务路径下的 class,找到 UpmResourceClient 并注入,因为该类注解了 @Service
,因此会注入到服务的 Spring 上下文中,UpmResourceClient 源码片段及主配置类如下:public class ThirdPartyBeanConfig {
@Bean
public UpmResourceClient upmResourceClient() {
return new UpmResourceClient();
}
}
Tips:如果该 Bean 还依赖其他 Bean,则需要把所依赖的 Bean 都注入; 针对 Bean 依赖情况复杂的场景梳理起来就比较麻烦了,所幸项目用到的服务 Bean 依赖关系都比较简单,一些依赖关系复杂的服务,观察到其路径扫描耗时也不是很高,就不处理了。
2.2 如何解决 Bean 初始化高耗时?
3 新的问题
CacheManager
,其实现类是 RedisCacheManager
:CacheManager
对象,为啥用到缓存的地方没有报错呢? 尝试在未添加扫描路径的情况下,从 ApplicationContext
中获取 CacheManager
类型的对象看下是否存在?结果发现确实存在 RedisCacheManager
对象:RedisCacheManager
并不是缓存组件代码中配置的,而是 SpringBoot 的自动化配置生成的,也就是说该对象并不是我们想要的对象,是不符合预期的,下文介绍其原因。3.1 SpringBoot 自动化装配,让人防不胜防
@SpringBootApplication
中的@EnableAutoConfiguration
上,该注解开启了 SpringBoot 的自动配置功能。该注解中的@Import(AutoConfigurationImportSelector.class)
通过加载 META-INF/spring.factories
下配置一系列 *AutoConfiguration 配置类,根据现有条件推断,尽可能地为我们配置需要的 Bean。这些配置类负责各个功能的自动化配置,其中用于 SpringBoot Cache 的自动配置类是 CacheAutoConfiguration
,接下来重点分析这个配置类就行了。@SpringBootApplication
复合注解中集成了三个非常重要的注解:@SpringBootConfiguration
、@EnableAutoConfiguration
、@ComponentScan
,其中@EnableAutoConfiguration
就是负责开启自动化配置功能; SpringBoot 中有多@EnableXXX
的注解,都是用来开启某一方面的功能,其实现原理也是类似的:通过@Import
筛选、导入满足条件的自动化配置类。
CacheAutoConfiguration
上有许多注解,重点关注下@Import({CacheConfigurationImportSelector.class})
,CacheConfigurationImportSelector
实现了 ImportSelector
接口,该接口用于动态选择想导入的配置类,这个 CacheConfigurationImportSelector
用来导入不同类型的 Cache 的自动配置类:CacheConfigurationImportSelector
发现,根据 SpringBoot 支持的缓存类型(CacheType),提供了 10 种 cache 的自动配置类,按优先级排序,最终只有一个生效,而本项目中恰恰就是 RedisCacheConfiguration
,其内部提供的是 RedisCacheManager
,和引入第三方缓存组件一样,所以造成了困惑:RedisCacheConfiguration
的实现:@EnableCaching
,开启了缓存功能,即使缓存组件没生效,SpringBoot 也会自动生成一个缓存管理对象;@ConditionalOnMissingBean(CacheManager.class)
失效;扫描路径不存在的话,SpringBoot 通过推断,自动生成一个缓存管理对象。RedisCacheConfiguration
中打断点,不删除扫描路径是走不到这边的 SpringBoot 自动装配过程的(缓存组件显式生成过了),删除了扫描路径是能走到的(SpringBoot 自动生成)。上文多次提到@Import,这是 SpringBoot 中重要注解,主要有以下作用: 1、导入 @Configuration
注解的类; 2、导入实现了ImportSelector
或ImportBeanDefinitionRegistrar
的类; 3、导入普通的 POJO。
3.2 使用 starter 机制,开箱即用
resources
文件中添加一个 META-INF/spring.factories
文件,在下面配置一个 EnableAutoConfiguration
即可,这样项目在启动时也会扫描到这个 jar 中的 spring.factories
文件,将 XxxAdCacheConfiguration
配置类自动引入,而不需要扫描"com.xxx.ad.rediscache"整个路径了:org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxx.ad.rediscache.XxxAdCacheConfiguration
SpringBoot 的 EnableAutoConfiguration
自动配置原理还是比较复杂的,在加载自动配置类前还要先加载自动配置的元数据,对所有自动配置类做有效性筛选,具体可查阅 EnableAutoConfiguration 相关代码。
END
往期精彩Spring Boot 集成 Liquibase,数据库也能做版本控制
Spring Batch 批量处理,骚气又强大!
Spring Cloud 使用 @RefreshScope 注解配置动态刷新
Spring Boot 关于日期时间格式化处理方式总结